Skip to content

feat(run): support fetching secrets from a non-default organization#300

Open
fdarian wants to merge 3 commits into
Infisical:mainfrom
fdarian:feat/multi-org-run
Open

feat(run): support fetching secrets from a non-default organization#300
fdarian wants to merge 3 commits into
Infisical:mainfrom
fdarian:feat/multi-org-run

Conversation

@fdarian

@fdarian fdarian commented Jul 6, 2026

Copy link
Copy Markdown

Description

Closes #299

If you belong to two organizations, log in and select org A, then infisical run in a project that lives in org B fails, even though your account has access to both. The stored session is a single org-scoped JWT, and run sends it against whatever workspaceId .infisical.json names with no awareness of which org that project is in, so the backend rejects the org-A token against an org-B workspace. Today the only workaround is to infisical login again and pick the other org.

The re-scoping mechanism to fix this already exists: infisical init lets a logged-in user pick a different organization mid-session and calls POST /v3/auth/select-organization with the current token to mint a token scoped to the chosen org. This PR reuses that same call in the secrets-fetch path.

Changes:

  • .infisical.json gains an optional organizationId, which infisical init now records for the selected project (additive, omitempty, existing config files keep working). When present it lets the fetch route to the right org directly.
  • infisical run gains an --organization-id flag.
  • The shared fetch path used by run and export resolves a target org id in priority order: --organization-id flag, then INFISICAL_ORGANIZATION_ID, then .infisical.json. If a target org is set and differs from the current token's organizationId claim, it mints a token for that org via select-organization and uses it for that invocation only. The stored keyring credential is never overwritten.
  • Self-healing fallback for configs that predate the field: if the fetch still returns the org-scope 403, it enumerates the organizations the account belongs to, re-scopes into each and retries, and on the first success persists the discovered organizationId back into .infisical.json so later runs route directly. This is what makes an existing project just work without a re-init.

When no target org is resolved and the fetch succeeds (single-org users, or a config already scoped to the right org), the code path and the number of requests are unchanged. Discovery only runs on the org-scope 403.

Type

  • Bug fix
  • New feature
  • Improvement
  • Breaking change
  • Documentation

Tests

Added unit tests in packages/util/secrets_org_scope_test.go for the non-network branches: the org-id claim decoder, the source-priority resolution, the 403 org-scope error predicate, and the .infisical.json writer round-trip. Gates:

go build ./...                 # passes
go test ./packages/util/...    # ok  github.com/Infisical/infisical-merge/packages/util
gofmt -l <changed files>       # clean

Manual repro, for an account that belongs to two orgs A and B with a project in each:

infisical login            # select org A
cd project-in-org-B        # existing .infisical.json, org-B workspace, no organizationId

# before this change: rejected, token is scoped to org A
#   403 This project does not belong to your selected organization
infisical run -- printenv  # after this change: discovery re-scopes to org B and it works,
                           # and organizationId is written back so the next run is a direct fetch

# explicit forms also work (and skip discovery):
infisical run --organization-id <orgB> -- printenv
INFISICAL_ORGANIZATION_ID=<orgB> infisical run -- printenv

Demo

2026-07-06.15-30-23.mp4

The demo uses @infisical, a locally built CLI carrying this change:

❯ which @infisical
/Users/<user>/.local/bin/@infisical
❯ readlink /Users/<user>/.local/bin/@infisical
/tmp/infisical-dev

Walkthrough with two organizations, A and B, each holding a project my account can access:

@infisical login                    # select org A

# an existing org-B project config (no organizationId): rejected on the current
# release, resolved automatically here via discovery
cd project-in-org-B
@infisical export                   # re-scopes to org B, and writes organizationId back

# explicit forms, no discovery needed:
@infisical run --organization-id <orgB> -- printenv
@infisical init && @infisical run -- printenv
More demos: writing organizationId into .infisical.json

Self-healing an existing config (organizationId written on first run)

2026-07-06.15-33-52.mp4
❯ cat .infisical.json          # existing config, no organizationId
{ "workspaceId": "<orgB-workspace>", "defaultEnvironment": "" }

@infisical run -- printenv     # 403, discovers org B, fetch succeeds

❯ cat .infisical.json          # organizationId now persisted, next run is a direct fetch
{ "workspaceId": "<orgB-workspace>", "organizationId": "<orgB>", "defaultEnvironment": "" }

infisical init records organizationId in a fresh config

2026-07-06.15-36-12.mp4
@infisical init                # select org B and its project

❯ cat .infisical.json          # written with organizationId from the selected org
{ "workspaceId": "<orgB-workspace>", "organizationId": "<orgB>", "defaultEnvironment": "" }

fdarian added 2 commits July 4, 2026 18:07
The CLI holds a single org-scoped JWT and uses it against whatever
workspace .infisical.json names, so `infisical run` against a project in
a different organization than the one selected at login is rejected by
the backend, even when the account has access to both organizations.

Resolve a target organization id from a new --organization-id flag, the
INFISICAL_ORGANIZATION_ID env var, or a new optional organizationId field
that `infisical init` now writes to .infisical.json. When it differs from
the current token's organizationId claim, mint a token scoped to that org
via the existing select-organization endpoint (the same call `infisical
init` already uses) and use it for that invocation only, without touching
the stored credential. When no target org is set, behavior is unchanged
and no extra request is made.
When the resolved token still gets a 403 "project does not belong to your
selected organization", enumerate the organizations the account belongs to,
re-scope into each and retry the fetch, and on the first success persist the
discovered organizationId into .infisical.json so later runs route directly.
This lets project configs that predate the organizationId field work without a
re-init. Discovery only runs on that 403, so correct-org and single-org fetches
make no extra requests.
@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds multi-org support to infisical run (and export via fallback): when the stored session token is scoped to org A but the workspace belongs to org B, the CLI now re-scopes via POST /v3/auth/select-organization rather than failing. The org ID resolves from --organization-id flag → INFISICAL_ORGANIZATION_ID env var → .infisical.json; on an unresolved 403 a self-healing discovery loop enumerates the user's orgs, retries each, and writes the discovered organizationId back to .infisical.json.

  • WorkspaceConfigFile gains an optional organizationId field (additive/omitempty); infisical init now writes it on project selection.
  • New helpers resolveOrgScopedToken and fetchSecretsWithOrgDiscovery are added in secrets.go, with unit tests covering the JWT decoder, priority resolution, 403 predicate, and config round-trip.
  • The run command receives an --organization-id flag; executeCommandWithWatchMode and fetchSecrets are updated to thread it through consistently.

Confidence Score: 3/5

The org-discovery fallback fires on every 403, not just the org-scoping case, which can cause multiple unexpected API calls and retry attempts when a user genuinely lacks permission to a workspace or secret path.

The token re-scoping and priority resolution logic is well-structured and tested. The main concern is isOrganizationScopeError checking only HTTP status code 403 without inspecting the error message: any permission denial (wrong workspace, missing secret-path access, revoked project access) silently triggers the full organization enumeration and retry loop before surfacing the original error. This is incorrect behavior on a common failure path.

packages/util/secrets.go — specifically the isOrganizationScopeError function and the fetchSecretsWithOrgDiscovery loop that relies on it.

Important Files Changed

Filename Overview
packages/util/secrets.go Core change: adds org-scoping logic (resolveOrgScopedToken, fetchSecretsWithOrgDiscovery, persistDiscoveredOrganizationId). The 403-based org-discovery trigger is too broad — any 403 (permission denial, wrong workspace, etc.) fires the enumeration loop.
packages/cmd/run.go Adds --organization-id flag and threads it through fetchSecrets / fetchAndFormatSecretsForShell / executeCommandWithWatchMode; plumbing is consistent and correct.
packages/cmd/init.go Now passes selectedOrgID into writeWorkspaceFile and delegates serialization to the new WriteWorkspaceConfigToPath helper; clean refactor.
packages/models/cli.go Adds OrganizationId (omitempty) to WorkspaceConfigFile and GetAllSecretsParameters; additive, backward-compatible.
packages/util/config.go Extracts WriteWorkspaceConfigToPath helper from init.go; straightforward refactor, no issues.
packages/util/secrets_org_scope_test.go New unit tests for getTokenOrganizationId, resolveOrgScopedToken, isOrganizationScopeError, and WriteWorkspaceConfigToPath; coverage is good for the non-network branches.

Reviews (1): Last reviewed commit: "feat(run): auto-discover a project's org..." | Re-trigger Greptile

Comment thread packages/util/secrets.go
Comment on lines +459 to +462
func isOrganizationScopeError(err error) bool {
var apiErr *api.APIError
return errors.As(err, &apiErr) && apiErr.StatusCode == 403
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 isOrganizationScopeError fires for every 403, not only the org-scope case. The Infisical backend returns distinct messages for authorization errors (e.g., "You don't have access to this project") versus org-scoping errors ("This project does not belong to your selected organization"). When a user genuinely lacks read permission on a workspace or secrets path, the current code will silently enumerate all of their organizations and retry — adding O(N orgs) extra API calls — before returning the original 403 unchanged. Consider checking the error message/name in addition to the status code, or at minimum document the intentional choice here.

Suggested change
func isOrganizationScopeError(err error) bool {
var apiErr *api.APIError
return errors.As(err, &apiErr) && apiErr.StatusCode == 403
}
func isOrganizationScopeError(err error) bool {
var apiErr *api.APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != 403 {
return false
}
// Only trigger org discovery for the specific org-scoping rejection.
// Generic permission/auth 403s should not silently enumerate all orgs.
return strings.Contains(apiErr.ErrorMessage, "does not belong to your selected organization")
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Narrowed isOrganizationScopeError to only match the org-scope rejection (403 plus the "does not belong to your selected organization" message), so a generic permission 403 no longer enumerates every organization. Added a test for the permission-403 case as well.

Fixed in 3990325.

isOrganizationScopeError matched every 403, so a generic permission
rejection would enumerate all of the account's organizations before
failing. Match the specific "does not belong to your selected
organization" backend message instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CLI cannot access projects across multiple organizations without re-logging in

1 participant